Skip to content

feat(server): server/discover handler; per-request clientCapabilities resolution (SEP-2575)#2064

Closed
felixweinberger wants to merge 2 commits into
fweinberger/s4-meta-scopefrom
fweinberger/f1-discover-caps
Closed

feat(server): server/discover handler; per-request clientCapabilities resolution (SEP-2575)#2064
felixweinberger wants to merge 2 commits into
fweinberger/s4-meta-scopefrom
fweinberger/f1-discover-caps

Conversation

@felixweinberger
Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger commented May 12, 2026


Registers a server/discover handler (returns serverInfo/capabilities/instructions without state). Server.buildContext resolves capability checks against ctx.mcpReq.clientCapabilities ?? this._clientCapabilities.

Motivation and Context

Completes the SEP-2575 server side: per-request peer scope + the server/discover method that replaces initialize for stateless clients.

How Has This Been Tested?

pnpm test:all passes. New tests for discover + per-request capability resolution.

Breaking Changes

None.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Depends on S4. Review guide: https://gist.github.com/felixweinberger/5a48e0f14d5aced39ed6a91b61940711.


@felixweinberger felixweinberger added the v2-stateless 2026-06 SDK: Protocol decomposition + SEP alignment (request-first / stateless) label May 12, 2026
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 12, 2026

⚠️ No Changeset found

Latest commit: a9cd645

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 12, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2064

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2064

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2064

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2064

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2064

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2064

commit: a9cd645

@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

1 similar comment
@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/server/src/server/server.ts
const resultSchemas: Record<string, z.core.$ZodType> = {
ping: EmptyResultSchema,
initialize: InitializeResultSchema,
'server/discover': ServerDiscoverResultSchema,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit: ServerDiscoverRequestSchema was added to the ClientRequestSchema union and ServerDiscoverResultSchema was added to the resultSchemas map, but ServerDiscoverResultSchema was not added to the ServerResultSchema union just above. Since ServerDiscoverResult omits the required protocolVersion field it is not covered by InitializeResultSchema, so the public ServerResult type / isSpecType.ServerResult exclude valid server/discover responses. Low impact (per-method validation goes through getResultSchema, which is wired), but worth a one-line addition for symmetry.

Extended reasoning...

What the issue is

The PR wires the new server/discover types into three of the four parallel registries in packages/core/src/types/schemas.ts but misses one:

Registry Updated?
ClientRequestSchema union ServerDiscoverRequestSchema added
resultSchemas map (used by getResultSchema) 'server/discover': ServerDiscoverResultSchema added
ResultTypeMap (types.ts) 'server/discover': ServerDiscoverResult added
ServerResultSchema union not added

ServerDiscoverResultSchema is defined as InitializeResultSchema.omit({ protocolVersion: true }). Because InitializeResultSchema requires protocolVersion: z.string() (non-optional), a discover result { capabilities, serverInfo, instructions? } is not structurally covered by the existing InitializeResultSchema member of the union, so the omission isn't redundant.

How it manifests

The ServerResultSchema union drives two public surfaces:

  1. type ServerResult in types.tsInfer<typeof ServerResultSchema>. The exported ServerResult TypeScript type does not include the ServerDiscoverResult variant.
  2. specTypeSchemas.ServerResult / isSpecType.ServerResult in specTypeSchema.ts — runtime validators built from the same union.

So a consumer using isSpecType.ServerResult(x) or narrowing on ServerResult won't see the discover variant as a first-class member.

Step-by-step

Take a valid server/discover response:

const r = { capabilities: { tools: {} }, serverInfo: { name: 'disc', version: '2.0.0' }, instructions: 'hello' };

Walk it through each ServerResultSchema union member:

  • EmptyResultSchema.strict(), rejects capabilities/serverInfo extras → ❌
  • InitializeResultSchema — requires protocolVersion: string → ❌
  • CompleteResultSchema / GetPromptResultSchema / List*ResultSchema / ReadResourceResultSchema / ListToolsResultSchema / GetTaskResultSchema / ListTasksResultSchema / CreateTaskResultSchema — each requires a field (completion, messages, prompts, resources, contents, tools, taskId/status, tasks, task) that the discover result lacks → ❌
  • CallToolResultSchema — extends loose ResultSchema with content: z.array(...).default([]), so it accepts r and injects content: [] → ✅ but mis-typed

So at runtime ServerResultSchema.safeParse(r) actually succeeds via the CallToolResultSchema branch (loose object + defaulted content), which means there's no runtime rejection — the practical effect is purely that the discover variant is missing from the static ServerResult type and from the intended union semantics.

Why existing code doesn't prevent it

There's no compile-time check tying ServerResultSchema's members to resultSchemas' values, and ServerResultSchema already has pre-existing drift (CancelTaskResultSchema and GetTaskPayloadResultSchema are in resultSchemas and in the spec mirror's ServerResult but not in this union), so the asymmetry slipped through. The vendored spec.types.ts also doesn't yet carry ServerDiscover*, so there's no spec-mirror diff to flag it either.

Impact

Low. The SDK's actual response validation path is getResultSchema(method)resultSchemas['server/discover'], which is wired, so nothing in Protocol/Dispatcher breaks. The only observable effects are on the public ServerResult type and on specTypeSchemas.ServerResult / isSpecType.ServerResult, and even there the loose CallToolResultSchema member happens to accept the payload at runtime. This is the repo's "Completeness — partial migration" pattern: a new type wired into one sibling registry but not the other.

Fix

One line — add ServerDiscoverResultSchema to the ServerResultSchema z.union([...]), e.g. right after InitializeResultSchema:

export const ServerResultSchema = z.union([
    EmptyResultSchema,
    InitializeResultSchema,
    ServerDiscoverResultSchema,
    CompleteResultSchema,
    ...
]);

Comment on lines 509 to +511
private async _createMessageVia(
send: SendWithSchema,
caps: ClientCapabilities | undefined,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The NOTE paragraph in this JSDoc is now stale: it still says the capability check "reads this._clientCapabilities, which is a singleton" and that "the per-request _meta.clientCapabilities flow fixes this in a follow-up", but this PR is that follow-up — the body now checks the injected caps parameter. The NOTE should be removed (or rewritten) so the doc no longer contradicts the implementation.

Extended reasoning...

What the issue is

The JSDoc on _createMessageVia (packages/server/src/server/server.ts:504-507) still contains:

NOTE: the capability check below reads this._clientCapabilities, which is a singleton (set on the most recent initialize). For multi-session handleHttp deployments this can read a different session's caps. The per-request _meta.clientCapabilities flow fixes this in a follow-up; until then, do not rely on the cap gate for isolation.

But this PR is exactly that follow-up. The diff changes the signature to:

private async _createMessageVia(
    send: SendWithSchema,
    caps: ClientCapabilities | undefined,
    ...
)

and the body now reads the injected parameter (if (!caps?.sampling), !caps.sampling.tools) rather than this._clientCapabilities. buildContext passes ctx.mcpReq.clientCapabilities ?? this._clientCapabilities into this slot, i.e. the per-request _meta.clientCapabilities flow the NOTE promised.

Why nothing prevents it

The PR edited the signature and body of _createMessageVia immediately below this comment but left the comment untouched. There's no lint or doc check that ties prose to implementation, so it silently went stale.

Step-by-step proof

  1. Before this PR, _createMessageVia had no caps parameter and the body read this._clientCapabilities?.sampling — the NOTE accurately described that.
  2. This PR adds caps: ClientCapabilities | undefined to the signature (server.ts:511) and rewrites the gate to if (!caps?.sampling) (server.ts:515).
  3. buildContext now computes reqCaps = ctx.mcpReq.clientCapabilities ?? this._clientCapabilities and threads it into _createMessageVia via requestSampling.
  4. Therefore the statement "the capability check below reads this._clientCapabilities" is false, and "fixes this in a follow-up; until then, do not rely on the cap gate for isolation" refers to a follow-up that has already landed in this very diff.

Impact

Documentation-only — no runtime effect. But the comment now actively misleads readers: it warns them not to rely on a cap gate that is, as of this PR, correctly per-request-scoped, and points them at a non-existent future fix. This is the pattern called out in the repo's Recurring Catches > Documentation & Changesets ("flag prose that now contradicts the implementation").

How to fix

Delete the NOTE paragraph (the four lines starting "NOTE: the capability check below…"). Optionally replace it with a one-liner noting that caps is the per-request resolved capabilities (_meta.clientCapabilities ?? singleton), but the new inline comment in buildContext already says that, so simply removing the stale NOTE is sufficient.

@felixweinberger felixweinberger force-pushed the fweinberger/s4-meta-scope branch from de534df to 28db3a3 Compare May 12, 2026 14:38
@felixweinberger felixweinberger force-pushed the fweinberger/f1-discover-caps branch from e3c311a to 0854b3b Compare May 12, 2026 14:38
@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/server/src/server/server.ts
@felixweinberger felixweinberger force-pushed the fweinberger/s4-meta-scope branch from 28db3a3 to 6c2c486 Compare May 12, 2026 15:57
@felixweinberger felixweinberger force-pushed the fweinberger/f1-discover-caps branch from 0854b3b to 0548336 Compare May 12, 2026 15:57
@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

@felixweinberger felixweinberger force-pushed the fweinberger/f1-discover-caps branch from 0548336 to a308a8c Compare May 12, 2026 16:31
Comment thread packages/server/src/server/server.ts Outdated
@felixweinberger felixweinberger force-pushed the fweinberger/s4-meta-scope branch from 3d3f254 to 740466a Compare May 12, 2026 17:28
@felixweinberger felixweinberger force-pushed the fweinberger/f1-discover-caps branch from a308a8c to a9cd645 Compare May 12, 2026 17:28
@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

Comment on lines 156 to 167
return {
...ctx,
mcpReq: {
...ctx.mcpReq,
log: async (level, data, logger) => {
if (this._capabilities.logging && !this.isMessageIgnored(level, ctx.sessionId)) {
if (this._capabilities.logging && !this.isMessageIgnored(level, ctx.sessionId, reqLogLevel)) {
await ctx.mcpReq.notify({ method: 'notifications/message', params: { level, data, logger } });
}
},
elicitInput: (params, options) => this._elicitInputVia(ctx.mcpReq.send, params, sendOpts(options)),
requestSampling: (params, options) => this._createMessageVia(ctx.mcpReq.send, params, sendOpts(options))
elicitInput: (params, options) => this._elicitInputVia(ctx.mcpReq.send, reqCaps, params, sendOpts(options)),
requestSampling: (params, options) => this._createMessageVia(ctx.mcpReq.send, reqCaps, params, sendOpts(options))
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit: buildContext computes a normalized reqCaps (via ClientCapabilitiesSchema.safeParse + singleton fallback) and threads it into _elicitInputVia/_createMessageVia, but the returned mcpReq spreads ...ctx.mcpReq without overriding clientCapabilities — so handlers that follow the JSDoc on BaseContext.mcpReq.clientCapabilities ("Prefer this over instance-level _clientCapabilities for per-request checks") still see the raw adapter-env value (or undefined on the connect() path) and can disagree with what ctx.mcpReq.elicitInput() actually allows. One-line fix: add clientCapabilities: reqCaps, after ...ctx.mcpReq,. (Residual surface of the resolved comment 3228255133 — option (c) was applied to the internal gate only.)

Extended reasoning...

What the issue is

a9cd645 fixes the cap-gate regression by computing a normalized per-request capabilities value in Server.buildContext:

const rawReqCaps = ctx.mcpReq.clientCapabilities;
const reqCaps =
    rawReqCaps === undefined ? this._clientCapabilities : (ClientCapabilitiesSchema.safeParse(rawReqCaps).data ?? rawReqCaps);

and threads reqCaps into _elicitInputVia / _createMessageVia. However, the returned context is:

mcpReq: {
    ...ctx.mcpReq,
    log: ...,
    elicitInput: ...,
    requestSampling: ...
}

The spread carries ctx.mcpReq.clientCapabilities through unchanged — reqCaps is never written back to the handler-visible field. So the internal SDK gate now sees the normalized value, but the public ctx.mcpReq.clientCapabilities field that handlers read still holds the raw dispatcher value.

Why this is the documented surface

The JSDoc on BaseContext.mcpReq.clientCapabilities (packages/core/src/shared/context.ts:182-187) explicitly tells SDK users:

Prefer this over instance-level _clientCapabilities for per-request checks on stateless servers.

So a handler that hand-checks a sub-capability before calling the helper — e.g. if (ctx.mcpReq.clientCapabilities?.elicitation?.form) { await ctx.mcpReq.elicitInput(...) } — is following the documented guidance, but reads a different value from the one ctx.mcpReq.elicitInput() itself uses.

The two divergent paths

  1. Adapter-env path (handleHttp + SessionCompat / shttpHandler): env.clientCapabilities is the raw wire object (isInitializeRequest is a boolean guard, no Zod transform), and dispatcher.ts assigns it to mcpReq.clientCapabilities unparsed. a9cd645 normalizes it into the local reqCaps, but the spread keeps the raw value in the field handlers see.
  2. connect() path: ctx.mcpReq.clientCapabilities is undefined (no _meta, no adapter env). reqCaps falls back to this._clientCapabilities (the Zod-parsed singleton), but the handler-visible field stays undefined.

In both cases the handler-visible field and the value the SDK helpers actually use can differ.

Step-by-step proof

  1. handleHttp deployment with SessionCompat. Client sends initialize with capabilities: { elicitation: {} } (spec-valid legacy shape). SessionCompat stores { elicitation: {} } raw.
  2. Same session sends tools/call. shttpHandler populates baseEnv.clientCapabilities = { elicitation: {} }; dispatcher assigns it to ctx.mcpReq.clientCapabilities unchanged.
  3. Server.buildContext runs: rawReqCaps = { elicitation: {} }; ClientCapabilitiesSchema.safeParse applies ElicitationCapabilitySchema's preprocess → reqCaps = { elicitation: { form: {} } }.
  4. Returned mcpReq: { ...ctx.mcpReq, log, elicitInput, requestSampling }mcpReq.clientCapabilities is still the raw { elicitation: {} }.
  5. Handler reads ctx.mcpReq.clientCapabilities?.elicitation?.formundefined → skips elicitation.
  6. Had the handler instead called ctx.mcpReq.elicitInput(...) directly, _elicitInputVia would evaluate !reqCaps.elicitation.form!{}false → request proceeds.

Same input, two answers. The connect()-path variant is similar: handler sees undefined, helper sees the populated singleton.

Why existing code doesn't prevent it

readMetaRequestScope (d887785) already normalizes the _meta-lifted value, so on the pure stateless-_meta path the field and reqCaps agree. The divergence is specific to (a) adapter-env (where dispatcher spreads adapter caps after readMetaRequestScope and never parses them) and (b) the singleton fallback. a9cd645 closed both for the internal gate but not for the exposed field — option (c) from review comment 3228255133 was applied to the gate's input only.

Impact

Low / nit. This is not a regression in the field itself — ctx.mcpReq.clientCapabilities was already raw on the adapter-env path and undefined on connect() before this PR. The SDK's own helpers (elicitInput/requestSampling) work correctly post-a9cd645. The only affected scenario is user code that manually inspects sub-capability shape on the documented field instead of (or before) calling the helper, which is narrow but explicitly endorsed by the JSDoc.

How to fix

One line in buildContext's returned object:

mcpReq: {
    ...ctx.mcpReq,
    clientCapabilities: reqCaps,
    log: ...,
    elicitInput: ...,
    requestSampling: ...
}

This makes the handler-visible field carry the same normalized + singleton-fallback value the SDK helpers use, keeping the documented contract consistent with actual behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2-stateless 2026-06 SDK: Protocol decomposition + SEP alignment (request-first / stateless)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant